This page requires JavaScript to display.
W
Unpacking...
← Data Engineering
Data Engineering

Data Warehouse Reference

OLTP vs OLAP, warehouse layers, schema design, slowly changing dimensions, ETL/ELT, and Redshift SQL.

Contents
1. Why a Warehouse 2. Warehouse Layers 3. Star & Snowflake Schemas 4. Slowly Changing Dimensions 5. ETL vs ELT 6. Redshift SQL 7. Fact Table Types 8. Factless Fact Tables 9. Conformed Dimensions 10. Query Performance 11. Normal Forms in Practice 12. Normalization Step by Step 13. Denormalization 14. Star & Snowflake Design Exercises 15. Modelling Interview Questions 16. High-Cardinality Columns 17. SCD Type 2 with Spark

1. Why a Warehouse

Operational systems (OLTP — Online Transactional Processing) and analytical systems (OLAP — Online Analytical Processing) have opposite needs. OLTP takes orders, fills stock, and handles complaints: one record at a time, fast writes, little history. OLAP answers "what's our best category?" or "how do sales compare to last month?": thousands of records scanned at once, fast read performance, deep historical context. A data warehouse is a database built and optimized specifically for that second job.

OLTP (operational)OLAP (analytical / DWH)
Access patternOne record at a timeThousands of records per query
PriorityFast, safe writesFast reads, historical context
HistoryLittle to none keptLong history retained deliberately

Warehouse goals: a centralized, consistent location for data pulled from many source systems; user-friendly enough for business users, not just engineers; loaded consistently and repeatedly via ETL; fast enough to serve reporting and visualization directly.

2. Warehouse Layers

Data lakes and warehouses both serve as centralized storage, but for different users and states of data: a lake holds raw, unstructured data for data scientists doing open-ended discovery; a warehouse holds processed, structured data for business users and IT running defined reports. Most organizations run both, side by side — see the AWS reference doc for the lake side of this comparison.

3. Star & Snowflake Schemas

Warehouse tables are modelled as fact tables (measurable events: an order, a click, a transaction; each row is one unit of activity at a declared grain) that reference dimension tables (the who/what/when/where context: customer, product, date, store). Star and snowflake schemas differ only in how the dimensions are structured:

SchemaDimensions are…In one line
StarDenormalized: one flat table per dimensionFewer joins, faster and simpler queries
SnowflakeNormalized: dimensions split into sub-tables along their hierarchy (roughly 3NF)Less redundancy, more joins

Star schema

The fact table sits in the middle and every dimension joins to it directly, so the diagram looks like a star. A dimension's whole hierarchy lives in one table: if 1,000 products belong to "Electronics", the word "Electronics" is stored 1,000 times. That redundancy is deliberate.

                 dim_date
                    │
   dim_store ── fact_sales ── dim_product
                    │          (product_name, subcategory, category,
              dim_customer      department — all in one table)
-- one join from the fact to the category
SELECT p.category, SUM(f.sales_amount) AS revenue
FROM fact_sales f
JOIN dim_product p ON p.product_key = f.product_key
GROUP BY p.category;

Snowflake schema

Dimensions are normalized into separate tables for each level of their hierarchy, so the diagram branches out like a snowflake. Each category name is stored once, but reaching it takes more joins.

   fact_sales ── dim_product ── dim_subcategory ── dim_category
        │
     dim_store ── dim_city ── dim_region
-- three joins from the fact to the category
SELECT c.category_name, SUM(f.sales_amount) AS revenue
FROM fact_sales f
JOIN dim_product     p ON p.product_key    = f.product_key
JOIN dim_subcategory s ON s.subcategory_id = p.subcategory_id
JOIN dim_category    c ON c.category_id    = s.category_id
GROUP BY c.category_name;

Star vs snowflake

StarSnowflake
DimensionsDenormalized (flat)Normalized (split by hierarchy)
Joins per queryFewerMore
Query performanceUsually fasterSlower on large data
Ease for BI usersSimpleMore complex
StorageMore (repeated values)Less
Changing an attribute (e.g. rename a category)Update many dimension rowsUpdate one row
Typical useThe default for data marts and BILarge shared hierarchies, strict storage or update needs

Common follow-up: "Is the fact table normalized?" In both schemas the fact table is normalized: it holds only foreign keys and measures. The schemas differ only in the dimensions, which is why a star schema is often described as "normalized facts, denormalized dimensions".

Which to choose?

4. Slowly Changing Dimensions

Dimension attributes change over time (a customer moves city, a product is re-categorized) and each SCD type handles that differently:

TypeBehavior
SCD 0Never changes — fixed attribute (e.g. date of birth)
SCD 1Overwrite the old value — no history kept
SCD 2Insert a new row with effective/expiry dates and a current-row flag — full history preserved
SCD 3Add a "previous value" column — keeps just one prior state
-- SCD Type 2 pattern
UPDATE dim_customer
SET expiry_date = CURRENT_DATE, is_current = false
WHERE customer_id = 101 AND is_current = true;

INSERT INTO dim_customer (customer_id, city, effective_date, expiry_date, is_current)
VALUES (101, 'Austin', CURRENT_DATE, NULL, true);

5. ETL vs ELT

6. Redshift SQL

Amazon Redshift is a managed, columnar, MPP (massively parallel processing) data warehouse — the AWS home for the core/access layer described above.

-- Load from S3 (COPY is Redshift's bulk-load command)
COPY sales.orders
FROM 's3://my-bucket/staging/orders/'
IAM_ROLE 'arn:aws:iam::123456789012:role/RedshiftLoadRole'
FORMAT AS PARQUET;

-- Distribution & sort keys control how rows spread across nodes
CREATE TABLE fact_sales (
    order_id BIGINT,
    customer_id INT,
    date_id INT,
    amount DECIMAL(10,2)
)
DISTKEY(customer_id)
SORTKEY(date_id);

-- Redshift Spectrum: query data lake files directly, no load needed
SELECT region, SUM(amount)
FROM spectrum_schema.external_orders
WHERE order_year = 2026
GROUP BY region;

DISTKEY picks which column co-locates rows on the same node (usually the most common join key, to avoid shuffling during joins); SORTKEY orders each node's blocks so range filters (like a date range) skip blocks entirely.

7. Fact Table Types

TypeGrainExample
Transaction factOne row per discrete eventOne row per order line
Periodic snapshotOne row per entity per fixed intervalEnd-of-day account balance, every day
Accumulating snapshotOne row per process instance, updated as it progressesOne row per order, columns for order/ship/deliver dates, updated as each milestone happens

Picking the grain first — "what does one row of this fact table mean?" — is the single most important dimensional-modeling decision; every measure in the table must be true at that grain.

8. Factless Fact Tables

A factless fact table is a fact table with no numeric measures: just foreign keys to dimensions (sometimes with a date or a degenerate ID). Each row records that an event happened or that a condition existed. You analyse it by counting rows rather than summing amounts.

KindRecordsExamples (one row per…)
Event trackingSomething happened, but there is nothing to add upStudent × class × date attended; customer × date × channel logged in; customer × branch × date visited
CoverageWhat could have happened, so you can find what did notProduct × store × day on promotion; customer × product they are eligible for; product × store × day in stock

Event tracking: attendance

CREATE TABLE fact_attendance (
  date_key    INT,          -- FK to dim_date
  student_key INT,          -- FK to dim_student
  course_key  INT           -- FK to dim_course
  -- no measures: the row itself is the event
);
-- How many attendances per course in October 2026?
SELECT c.course_name, COUNT(*) AS attendances
FROM fact_attendance f
JOIN dim_course c ON c.course_key = f.course_key
JOIN dim_date   d ON d.date_key   = f.date_key
WHERE d.year = 2026 AND d.month = 10
GROUP BY c.course_name
ORDER BY attendances DESC;

Coverage: finding what did not happen

A normal sales fact only has rows for things that sold, so it cannot answer "which promoted products did not sell?". A coverage table stores every product that was on promotion; an anti-join against the sales fact gives the gaps:

-- fact_promo_coverage: one row per product × store × day on promotion
SELECT pc.date_key, pc.store_key, pc.product_key
FROM fact_promo_coverage pc
LEFT JOIN fact_sales f
       ON f.date_key    = pc.date_key
      AND f.product_key = pc.product_key
      AND f.store_key   = pc.store_key
      AND f.promo_key   = pc.promo_key
WHERE f.product_key IS NULL;          -- promoted, but no promoted sale

Banking examples

Factless factQuestion it answers
Customer × product eligibility (by month)Eligible customers who have not taken the product: a cross-sell list (eligibility minus holdings)
Customer × KYC document submittedWhich customers are missing a required document
Account × day with no activityDormant accounts for regulatory reporting
Customer × branch × date visitedBranch footfall, and which customers never visit branches
-- cross-sell: eligible this month but not holding the product
SELECT e.customer_key, e.product_key
FROM fact_product_eligibility e
LEFT JOIN fact_product_holding h
       ON h.customer_key = e.customer_key
      AND h.product_key  = e.product_key
      AND h.month_key    = e.month_key
WHERE e.month_key = 202610
  AND h.customer_key IS NULL;

Interview points

9. Conformed Dimensions

A conformed dimension (e.g. dim_date, dim_customer) is built once and shared across every fact table and data mart that needs it, with identical column names, keys and meaning everywhere. Without conformance, "customer" in the Sales mart and "customer" in the Support mart can silently mean different things, making cross-mart reporting unreliable.

A bus matrix — rows of business processes (facts) against columns of shared dimensions — is the standard planning tool for keeping dimensions conformed as a warehouse grows across teams.

10. Query Performance

CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT date_id, region, SUM(amount) AS total_sales
FROM fact_sales
GROUP BY date_id, region;

REFRESH MATERIALIZED VIEW mv_daily_sales;

11. Normal Forms in Practice

3NF is the usual target for transactional models. 1NF and 2NF are steps on the way (1NF ⊂ 2NF ⊂ 3NF ⊂ BCNF), and 2NF only matters when the key is composite.

FormRuleViolation → fix
1NFAtomic values, no repeating groupsphones = '98200…, 98201…' → one row per phone in a child table
2NFNo dependency on part of a composite key(account_id, product_code) → product_name depends only on product_code → product table
3NFNo transitive dependencyaccount → branch_id → branch_city → move city to a branch table

Which form, where

LayerFormWhy
OLTP source systems3NF (sometimes BCNF)Updates without anomalies
Inmon EDW / Data Vault3NF / highly normalisedIntegrated, auditable history
Bronze / stagingNot normalisedRaw, as received
SilverAt least 1NF; 3NF-like entitiesClean, joinable data
Gold / martsDenormalised star schemasFast analytical reads
NoSQL servingDenormalised documentsOne lookup per entity
# 1NF in Spark: explode an array column into one row per value
phones_1nf = raw_customers.select("customer_id",
                                  F.explode("phones").alias("phone_number"))

12. Normalization Step by Step

Normalization is the process of organising tables so that each fact is stored once, in the table whose key it truly depends on. It is driven by functional dependencies: A → B means "if you know A, you know exactly one B" (e.g. customer_id → customer_city). Normalized designs avoid three kinds of anomaly:

AnomalyExample in an un-normalized orders table
Update anomalyA customer moves city: you must update every one of their order rows, and missing one leaves contradictory data
Insert anomalyYou cannot record a new product until someone orders it, because product data only exists on order rows
Delete anomalyDeleting a customer's only order also deletes the only record of that customer

Worked example: from a spreadsheet to BCNF

0NF (unnormalized). One row per order, with a repeating group of products in a single cell:

order_idorder_datecustomer_idcustomer_namecustomer_cityitems
10012026-10-01C1AshaPuneP1 Laptop ×1 @ 50000; P2 Mouse ×2 @ 500
10022026-10-02C2BenDelhiP2 Mouse ×1 @ 450
10032026-10-05C1AshaPuneP3 Monitor ×1 @ 12000

1NF: atomic values, no repeating groups. One row per order line; the key becomes (order_id, product_id):

order_idproduct_idorder_datecustomer_idcustomer_namecustomer_cityproduct_nameqtyunit_price
1001P12026-10-01C1AshaPuneLaptop150000
1001P22026-10-01C1AshaPuneMouse2500
1002P22026-10-02C2BenDelhiMouse1450
1003P32026-10-05C1AshaPuneMonitor112000

Queryable now, but all three anomalies remain: Asha's city is stored three times.

2NF: no partial dependencies on part of a composite key. order_date and the customer columns depend only on order_id; product_name depends only on product_id. Split them out:

orders      (order_id PK, order_date, customer_id, customer_name, customer_city)
products    (product_id PK, product_name, list_price)
order_lines (order_id, product_id, qty, unit_price,  PK (order_id, product_id))

Notice unit_price stays on order_lines: the price actually charged depends on the order line (Ben paid 450 for a mouse listed at 500). Only the current list price belongs to products. Telling these apart is a favourite interview follow-up.

3NF: no transitive dependencies (non-key column → non-key column). In orders, order_id → customer_id → customer_name, customer_city, so customer data moves to its own table:

customers   (customer_id PK, customer_name, customer_city)
orders      (order_id PK, order_date, customer_id FK → customers)
products    (product_id PK, product_name, list_price)
order_lines (order_id FK, product_id FK, qty, unit_price, PK (order_id, product_id))

Each fact now lives once: a city change is one UPDATE, products exist without orders, and deleting an order keeps the customer.

BCNF: every determinant is a candidate key. 3NF tables can still break this when there are overlapping candidate keys. Example: each customer has one advisor per product type, and each advisor handles exactly one product type:

customer_idproduct_typeadvisor_id
C1LOANA7
C2LOANA7
C1CARDA9

The key is (customer_id, product_type), but advisor_id → product_type and advisor_id is not a key, so A7's product type is repeated and could become inconsistent. BCNF decomposition: advisors(advisor_id PK, product_type) and customer_advisors(customer_id, advisor_id). The trade-off: the rule "one advisor per product type per customer" can no longer be enforced by a single table key (BCNF does not always preserve dependencies).

Benefit of normalizingCost
Each fact stored once: no update, insert or delete anomaliesMore tables and joins to answer questions
Smaller rows, efficient writes, strong integrity via keysAnalytical queries become complex and slower at scale
Schema changes are localBI users struggle with many-table models

13. Denormalization

Denormalization deliberately stores redundant or pre-joined data to make reads faster and simpler. It is not "bad design": it is the right choice for analytical workloads that read far more than they write, and where pipelines (not users) keep the copies consistent.

Normalized (OLTP)Denormalized (OLAP)
Optimised forMany small reads and writes, integrityLarge scans and aggregations
Typical form3NF / BCNFStar schema, wide tables, aggregates
Joins per queryManyFew
RedundancyMinimalIntentional
Who keeps data consistentKeys and constraintsETL/ELT pipelines (rebuilds, MERGE, SCD)
TechniqueExample
Flattened dimensions (star schema)dim_product carries subcategory, category and department names instead of separate tables
Redundant columns on factsStore customer_segment or region on the fact so common filters need no join
Derived / precomputed columnsline_amount = qty × unit_price, profit = revenue − cost
Aggregate (summary) tablesdaily_sales_by_store, refreshed after each load
Materialized viewsDatabase-maintained precomputed joins or aggregates
One Big Table (OBT)A single wide table for a dashboard or ML feature set
Nested / repeated columnsOrder with an array of line items (BigQuery STRUCT/ARRAY, Spark structs, document stores)

Example: building a denormalized sales table from the 3NF model

CREATE TABLE sales_wide AS
SELECT ol.order_id,
       o.order_date,
       c.customer_id, c.customer_name, c.customer_city,      -- copied from customers
       p.product_id,  p.product_name,                       -- copied from products
       ol.qty, ol.unit_price,
       ol.qty * ol.unit_price AS line_amount                 -- derived column
FROM order_lines ol
JOIN orders    o ON o.order_id    = ol.order_id
JOIN customers c ON c.customer_id = o.customer_id
JOIN products  p ON p.product_id  = ol.product_id;

-- dashboards now query one table with no joins
SELECT customer_city, SUM(line_amount) AS revenue
FROM sales_wide
GROUP BY customer_city;

14. Star & Snowflake Design Exercises

Interviewers usually give a business scenario and ask you to design the model, then write queries against it. Always answer in this order: business process → grain → dimensions → facts (measures), then mention SCDs and special cases.

Exercise 1: Design a star schema for a retail chain

Q. A retailer with 500 stores wants to analyse sales by product, store, date and promotion. Design the schema.
          dim_date ─────┐                 ┌───── dim_product
  (date_key, date,      │                 │      (product_key, sku, product_name,
   month, quarter, year)│                 │       subcategory, category, brand)
                        ▼                 ▼
                    ┌──────── fact_sales ────────┐
                    │ date_key, product_key,      │
                    │ store_key, promo_key,       │
                    │ receipt_number (degenerate),│
                    │ qty, sales_amount,          │
                    │ discount_amount, cost_amount│
                    └─────────────────────────────┘
                        ▲                 ▲
          dim_store ────┘                 └───── dim_promotion
  (store_key, store_name,                        (promo_key, promo_name,
   city, region)                                  promo_type, start_date, end_date)
CREATE TABLE dim_date (
  date_key   INT PRIMARY KEY,          -- 20261026
  full_date  DATE,
  month      INT,
  month_name VARCHAR(10),
  quarter    INT,
  year       INT
);
CREATE TABLE dim_product (
  product_key  INT PRIMARY KEY,         -- surrogate key
  sku          VARCHAR(20),             -- natural/business key
  product_name VARCHAR(100),
  subcategory  VARCHAR(50),
  category     VARCHAR(50)
);
CREATE TABLE dim_store (
  store_key  INT PRIMARY KEY,
  store_name VARCHAR(100),
  city       VARCHAR(50),
  region     VARCHAR(50)
);
CREATE TABLE dim_promotion (
  promo_key  INT PRIMARY KEY,
  promo_name VARCHAR(100),
  promo_type VARCHAR(30)                 -- includes a "No promotion" row (key 0)
);
CREATE TABLE fact_sales (
  date_key        INT REFERENCES dim_date(date_key),
  product_key     INT REFERENCES dim_product(product_key),
  store_key       INT REFERENCES dim_store(store_key),
  promo_key       INT REFERENCES dim_promotion(promo_key),
  receipt_number  VARCHAR(30),
  qty             INT,
  sales_amount    DECIMAL(12,2),
  discount_amount DECIMAL(12,2),
  cost_amount     DECIMAL(12,2)
);

Use a "No promotion" row (key 0) instead of NULL foreign keys, so inner joins never drop sales and reports show an explicit label.

Q. Monthly revenue and profit by category and region for 2026.
SELECT d.year, d.month, p.category, s.region,
       SUM(f.sales_amount)                 AS revenue,
       SUM(f.sales_amount - f.cost_amount) AS profit
FROM fact_sales f
JOIN dim_date    d ON d.date_key    = f.date_key
JOIN dim_product p ON p.product_key = f.product_key
JOIN dim_store   s ON s.store_key   = f.store_key
WHERE d.year = 2026
GROUP BY d.year, d.month, p.category, s.region
ORDER BY d.month, revenue DESC;
Q. Top 2 products by revenue in each store.
SELECT store_name, product_name, revenue
FROM (
  SELECT s.store_name, p.product_name, SUM(f.sales_amount) AS revenue,
         ROW_NUMBER() OVER (PARTITION BY s.store_key ORDER BY SUM(f.sales_amount) DESC) AS rn
  FROM fact_sales f
  JOIN dim_store   s ON s.store_key   = f.store_key
  JOIN dim_product p ON p.product_key = f.product_key
  GROUP BY s.store_key, s.store_name, p.product_name
) ranked
WHERE rn <= 2
ORDER BY store_name, revenue DESC;
Q. How much revenue came from promotions vs no promotion, by quarter?
SELECT d.year, d.quarter,
       SUM(CASE WHEN pr.promo_key <> 0 THEN f.sales_amount ELSE 0 END) AS promo_revenue,
       SUM(CASE WHEN pr.promo_key =  0 THEN f.sales_amount ELSE 0 END) AS regular_revenue
FROM fact_sales f
JOIN dim_date      d  ON d.date_key   = f.date_key
JOIN dim_promotion pr ON pr.promo_key = f.promo_key
GROUP BY d.year, d.quarter
ORDER BY d.year, d.quarter;

Exercise 2: Snowflake vs star for the same question

Q. The product dimension is snowflaked: product → subcategory → category. Write revenue by category, then convert it to a star.
CREATE TABLE sf_category    (category_id INT PRIMARY KEY, category_name VARCHAR(50));
CREATE TABLE sf_subcategory (subcategory_id INT PRIMARY KEY, subcategory_name VARCHAR(50),
                             category_id INT REFERENCES sf_category(category_id));
CREATE TABLE sf_product     (product_key INT PRIMARY KEY, product_name VARCHAR(100),
                             subcategory_id INT REFERENCES sf_subcategory(subcategory_id));
-- snowflake: three hops from the fact to reach the category
SELECT c.category_name, SUM(f.sales_amount) AS revenue
FROM fact_sales f
JOIN sf_product     p  ON p.product_key     = f.product_key
JOIN sf_subcategory sc ON sc.subcategory_id = p.subcategory_id
JOIN sf_category    c  ON c.category_id     = sc.category_id
GROUP BY c.category_name;
-- flatten the hierarchy into one star dimension (rebuilt by the pipeline)
CREATE TABLE dim_product_flat AS
SELECT p.product_key, p.product_name,
       sc.subcategory_name AS subcategory,
       c.category_name     AS category
FROM sf_product p
JOIN sf_subcategory sc ON sc.subcategory_id = p.subcategory_id
JOIN sf_category    c  ON c.category_id     = sc.category_id;

-- star: one hop
SELECT p.category, SUM(f.sales_amount) AS revenue
FROM fact_sales f
JOIN dim_product_flat p ON p.product_key = f.product_key
GROUP BY p.category;
Prefer star whenSnowflake can make sense when
BI users write or generate the SQL; simplicity mattersA large hierarchy is shared by several dimensions (e.g. geography used by store, customer and supplier)
Columnar storage makes repeated attribute values cheapA sub-dimension changes often and must be maintained in one place
Query speed and fewer joins matter mostTools or source models already depend on the normalized hierarchy

Exercise 3: A banking transactions mart

Q. Design a model to analyse card and UPI transactions by customer, account, merchant, channel and date. Accounts can be jointly owned, customer segments change over time, and each transaction has several yes/no flags.
Design elementChoiceWhy
GrainOne row per transactionThe most atomic level; supports any aggregation
dim_customerSCD Type 2 (segment, city)Report each transaction against the customer's attributes at that time
dim_date (role-playing)Used twice: transaction date and posting dateOne physical date table, joined under two aliases
dim_txn_flags (junk dimension)All combinations of is_international, is_card_present, is_reversedAvoids many tiny flag columns or dimensions on the fact
txn_idDegenerate dimension on the factIdentifier with no other attributes
bridge_account_customeraccount_key, customer_key, allocation_pctResolves the many-to-many joint ownership between accounts and customers
CREATE TABLE dim_customer (
  customer_key   INT PRIMARY KEY,       -- one row per version (SCD2)
  customer_id    VARCHAR(20),
  segment        VARCHAR(20),
  effective_from DATE, effective_to DATE, is_current INT
);
CREATE TABLE dim_account (account_key INT PRIMARY KEY, account_no VARCHAR(20), product VARCHAR(20));
CREATE TABLE bridge_account_customer (
  account_key    INT REFERENCES dim_account(account_key),
  customer_key   INT REFERENCES dim_customer(customer_key),
  allocation_pct DECIMAL(5,2)              -- shares per account sum to 1.00
);
CREATE TABLE dim_txn_flags (
  flags_key INT PRIMARY KEY, is_international INT, is_card_present INT, is_reversed INT
);
CREATE TABLE fact_card_txn (
  txn_id         VARCHAR(30),               -- degenerate dimension
  txn_date_key   INT,                       -- role-playing dim_date #1
  post_date_key  INT,                       -- role-playing dim_date #2
  account_key    INT REFERENCES dim_account(account_key),
  flags_key      INT REFERENCES dim_txn_flags(flags_key),
  channel        VARCHAR(10),
  amount         DECIMAL(12,2)
);
Q. Total spend per customer, splitting joint accounts by ownership share, excluding reversed transactions.
SELECT c.customer_id,
       SUM(f.amount * b.allocation_pct) AS allocated_spend
FROM fact_card_txn f
JOIN dim_txn_flags fl ON fl.flags_key = f.flags_key
JOIN bridge_account_customer b ON b.account_key = f.account_key
JOIN dim_customer c ON c.customer_key = b.customer_key
WHERE fl.is_reversed = 0
GROUP BY c.customer_id
ORDER BY c.customer_id;

Without the allocation, a ₹1,000 transaction on a joint account would count ₹1,000 for each owner and the grand total would be overstated. This "double counting through a bridge" is exactly what interviewers check.

Q. Transactions posted on a different day from when they happened (role-playing dates).
SELECT f.txn_id, td.full_date AS txn_date, pd.full_date AS posting_date
FROM fact_card_txn f
JOIN dim_date td ON td.date_key = f.txn_date_key     -- same table,
JOIN dim_date pd ON pd.date_key = f.post_date_key    -- two roles
WHERE f.txn_date_key <> f.post_date_key;

Exercise 4: Which kind of fact table?

RequirementFact typeGrain
Daily closing balance of every accountPeriodic snapshotOne row per account per day (balances are semi-additive: never sum across days)
Track each loan application from submitted → approved → disbursedAccumulating snapshotOne row per application, milestone dates updated as it progresses
Which products were on promotion but did not sell?Factless fact (coverage)One row per product per store per day on promotion, compared with fact_sales
Every card swipeTransaction factOne row per transaction
Q. Using a factless promotion-coverage table, list promoted products with no sales.
CREATE TABLE fact_promo_coverage (date_key INT, product_key INT, store_key INT, promo_key INT);

SELECT pc.date_key, pc.store_key, pc.product_key
FROM fact_promo_coverage pc
LEFT JOIN fact_sales f
       ON f.date_key = pc.date_key AND f.product_key = pc.product_key
      AND f.store_key = pc.store_key AND f.promo_key = pc.promo_key
WHERE f.product_key IS NULL;

Exercise 5: Year-over-year growth on a star schema

Q. Show yearly revenue per category and the growth % versus the previous year.
WITH yearly AS (
  SELECT d.year, p.category, SUM(f.sales_amount) AS revenue
  FROM fact_sales f
  JOIN dim_date d    ON d.date_key    = f.date_key
  JOIN dim_product p ON p.product_key = f.product_key
  GROUP BY d.year, p.category
)
SELECT year, category, revenue,
       LAG(revenue) OVER (PARTITION BY category ORDER BY year) AS prev_revenue,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (PARTITION BY category ORDER BY year))
             / LAG(revenue) OVER (PARTITION BY category ORDER BY year), 1) AS growth_pct
FROM yearly
ORDER BY category, year;

15. Modelling Interview Questions

QuestionShort answer
Normalization vs denormalization?Normalization stores each fact once to avoid anomalies (OLTP); denormalization adds controlled redundancy for fast, simple reads (OLAP), kept consistent by pipelines
Explain 1NF, 2NF and 3NF in one line each.1NF: atomic values, no repeating groups. 2NF: no column depends on part of a composite key. 3NF: no non-key column depends on another non-key column
What is BCNF?Every determinant is a candidate key; stricter than 3NF and occasionally gives up dependency preservation
Is a star schema normalized?Facts are normalized (keys and measures), dimensions are deliberately denormalized; a snowflake normalizes the dimensions
What is the grain, and why decide it first?What one fact row represents; it fixes which dimensions and measures are valid and prevents double counting
Surrogate vs natural key?Natural keys come from sources; surrogate keys are warehouse integers that survive source changes, enable SCD2 and join faster
What are degenerate, junk and role-playing dimensions?Degenerate: an ID kept on the fact with no table (receipt number). Junk: one small table of low-cardinality flags. Role-playing: one dimension used in several roles (order date, ship date)
How do you model many-to-many (joint accounts)?A bridge table between the fact's dimension and the related dimension, with an allocation factor to avoid double counting
Additive vs semi-additive vs non-additive?Additive sums across all dimensions (sales); semi-additive not across time (balances); non-additive never sums (ratios: store components and compute)
Kimball vs Inmon?Kimball: bottom-up dimensional marts with conformed dimensions. Inmon: top-down normalized enterprise warehouse feeding marts. Many modern platforms combine a normalized/silver layer with dimensional gold marts

16. High-Cardinality Columns

Cardinality is the number of distinct values in a column. High: close to the row count (txn_id, account_id, email). Low: a few repeating values (channel, status, currency). Measure with COUNT(DISTINCT col) or approx_count_distinct(col).

UseLow cardinalityHigh cardinality
Partition keyGood if medium (e.g. date); too few values → skewNever: millions of tiny files
Clustering / Z-order keyLittle benefitIdeal
GROUP BYFew huge groups (skew risk)Many groups (shuffle, memory)
RDBMS indexBitmapB-tree
CompressionExcellent (dictionary encoding)Lower
Kafka message keyHot partitionsEven spread
Streaming stateSmallLarge; needs RocksDB and TTLs

Rule: partition only by low-to-medium cardinality columns that queries filter on (usually a date, aiming for ≥ ~1 GB per partition); use clustering for high-cardinality filter columns. Rapidly changing high-cardinality attributes in SCD2 dimensions belong in banded mini-dimensions.

17. SCD Type 2 with Spark

Partitioning is not required for SCD2. First prepare the source: one row per business key, plus a hash of the tracked columns.

TRACKED = ["name", "city", "segment"]
latest = Window.partitionBy("customer_id").orderBy(F.col("updated_at").desc())

src = (raw.withColumn("_rn", F.row_number().over(latest))
          .filter("_rn = 1").drop("_rn")
          # NULL-safe: concat_ws skips NULLs, so a change to NULL would be missed
          .withColumn("hash_diff", F.sha2(F.to_json(F.struct(*TRACKED)), 256))
          .withColumn("effective_from", F.to_date(F.lit(load_date))))

Method A: Delta, one MERGE with a staged union

Changed rows appear twice in the source: once with their key (to close the current row) and once with a NULL key (which never matches, so it is inserted as the new version).

MERGE INTO dim_customer AS t
USING (
  SELECT s.customer_id AS merge_key, s.* FROM stg_customer s
  UNION ALL
  SELECT NULL AS merge_key, s.*
  FROM stg_customer s
  JOIN dim_customer d
    ON d.customer_id = s.customer_id AND d.is_current AND d.hash_diff <> s.hash_diff
) AS s
ON t.customer_id = s.merge_key AND t.is_current = true
WHEN MATCHED AND t.hash_diff <> s.hash_diff THEN
  UPDATE SET t.is_current = false, t.effective_to = date_sub(s.effective_from, 1)
WHEN NOT MATCHED THEN
  INSERT (customer_sk, customer_id, name, city, segment, hash_diff,
          effective_from, effective_to, is_current)
  VALUES (xxhash64(s.customer_id, s.effective_from), s.customer_id, s.name, s.city,
          s.segment, s.hash_diff, s.effective_from, DATE'9999-12-31', true);
Source rowResult
UnchangedMatches, hashes equal → no action
Changed, copy with keyMatches the current row → closed
Changed, copy with NULL keyNo match → inserted as new current version
New customerNo match → inserted

The MERGE is atomic and idempotent: rerunning the same batch changes nothing. Even without partitions, Delta rewrites only the files that contain matched rows.

Method B: Parquet / Hive, rebuild and swap

Build the new dimension as history ∪ unchanged current rows ∪ changed rows closed ∪ new versions and new keys (a full outer join of current vs source). Spark cannot overwrite a path it is reading, so write to a new location and swap with ALTER TABLE … SET LOCATION.